feat: add mod editing, revision merges and secure game storage

Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
This commit is contained in:
mrleo1nid committed 2026-10-25 04:42:48 +03:00
1 parent 06ff19ac4a
commit c8d83b3fe7
65 files changed
+3825 -68

No files matched your search

+2 -2
View File
@@ -18,8 +18,8 @@ transform slightleft:
xalign 0.3 yalign 1.0
init python:
def helper():
return 1
bonus = 5
persistent.seen_intro = False
+100
View File
@@ -0,0 +1,100 @@
"""Disposable local UI fixture. Run seed/cleanup only on the pgames-test stand."""
import argparse
import asyncio
import json
import tempfile
from pathlib import Path
from uuid import uuid4
from sqlalchemy import delete
from app.config import get_settings
from app.converter.pipeline import convert_archive
from app.db import SessionLocal
from app.maintenance import storage_access
from app.models import Game, GameVersion, Role, User
from app.security import hash_password
from tests.make_sample import build
async def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("seed", "check", "cleanup"))
args = parser.parse_args()
record = get_settings().data_dir / "mod-ui-fixture.json"
if args.action == "check":
import http.cookiejar
import urllib.error
import urllib.request
data = json.loads(record.read_text())
host = "http://web"
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
payload = json.dumps({"username": data["username"], "password": data["password"]}).encode()
opener.open(urllib.request.Request(host + "/api/auth/login", data=payload, headers={"Content-Type": "application/json"}))
manifest = json.load(opener.open(host + f'/api/versions/{data["version_id"]}/manifest'))
key = manifest["files"]["game.json"]["hash"]
file_url = host + f'/api/versions/{data["version_id"]}/files/{key}'
response = opener.open(file_url)
assert response.status == 200 and "private" in response.headers["Cache-Control"]
assert json.load(response)["format"] == "pgvn/1"
assert opener.open(urllib.request.Request(file_url, method="HEAD")).status == 200
audio = next(f["hash"] for p, f in manifest["files"].items() if p.endswith(".ogg"))
response = opener.open(urllib.request.Request(host + f'/api/versions/{data["version_id"]}/files/{audio}', headers={"Range": "bytes=0-9"}))
assert response.status == 206 and len(response.read()) == 10
for url, status in [(file_url, 401), (host + f"/_protected_blobs/{key[:2]}/{key[2:4]}/{key}", 404)]:
try:
urllib.request.urlopen(url)
except urllib.error.HTTPError as error:
assert error.code == status
else:
raise AssertionError("Unprotected media route")
print("nginx: authenticated JSON/HEAD/Range OK; anonymous and direct internal access denied")
return
async with storage_access():
async with SessionLocal() as db:
if args.action == "cleanup":
if not record.exists():
return
data = json.loads(record.read_text())
user = await db.get(User, data["user_id"])
if not user or user.username != data["username"] or not user.username.startswith("mod-ui-"):
raise RuntimeError("Fixture identity mismatch; cleanup aborted")
await db.execute(delete(Game).where(Game.created_by_id == user.id))
await db.delete(user)
await db.commit()
await db.execute(delete(Role).where(Role.id == data["role_id"]))
await db.commit()
record.unlink()
print("Disposable UI user and games removed")
return
if record.exists():
print(record.read_text())
return
suffix = uuid4().hex[:10]
password = uuid4().hex
role = Role(name="mod-ui-" + suffix, permissions=["games.play", "versions.fork", "games.upload", "games.edit", "versions.publish"])
db.add(role)
await db.flush()
user = User(username="mod-ui-" + suffix, password_hash=hash_password(password), role_id=role.id, must_change_password=False)
db.add(user)
await db.flush()
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
result = await asyncio.to_thread(convert_archive, build(root / "sample.zip"), "sample.zip", root / "work", lambda _: None)
game = Game(title="Проверка редактора модов", created_by_id=user.id)
db.add(game)
await db.flush()
original = GameVersion(game_id=game.id, owner_id=user.id, name="Оригинал для проверки", status="ready", manifest=result.manifest)
db.add(original)
await db.flush()
fork = GameVersion(game_id=game.id, parent_id=original.id, owner_id=user.id, name="Мой тестовый мод", kind="mod", visibility="draft", status="ready", manifest=result.manifest, base_revision_id=original.revision_id)
fork._revision_parents = [original.revision_id]
db.add(fork)
await db.commit()
data = dict(username=user.username, password=password, user_id=user.id, role_id=role.id, version_id=fork.id, game_id=game.id)
record.write_text(json.dumps(data))
print(json.dumps(data))
if __name__ == "__main__":
asyncio.run(main())
+279
View File
@@ -0,0 +1,279 @@
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
import zipfile
import pytest
from app.config import get_settings
from app.converter.compatibility import CompatibilityError, audit_ir, detect, require_supported_scripts
from app.converter.nani_compiler import compile_package
from app.converter.nani_expr import assignment, parse
from app.converter.nani_package import Package, decode_script, text_records
from app.converter.pipeline import convert_archive
from app.converter.preflight import inspect_directory, probe_archive
from app.converter.rpy_parser import RpyParser, ScriptData
from app.import_library import check, entries
from app.translation import LineInfo, validate_nani_translation
def write(path, text=""):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def script(source):
data = ScriptData()
RpyParser(data, "script.rpy").parse(source)
return data
def package(*commands):
return Package(scripts={"Opening": {"name": "Opening", "lines": [
{"line": i, "commands": [command]} for i, command in enumerate(commands)]}})
def command(op, **args):
return {"op": op, "args": args}
@pytest.mark.parametrize("engine,marker", [
("unity", "Game_Data/Managed/Assembly-CSharp.dll"),
("unity", "Game_Data/il2cpp_data/Metadata/global-metadata.dat"),
("gamemaker", "data.win"), ("godot", "Game.pck"), ("rpgmaker", "Game.rgss3a")])
def test_unsupported_engines_fail_with_reason(tmp_path, engine, marker):
write(tmp_path / marker)
with pytest.raises(CompatibilityError) as exc:
detect(tmp_path)
assert exc.value.engine == engine
assert exc.value.report()["issues"][0]["code"] == "unsupported_engine"
def test_naninovel_detection_ignores_migration_copies(tmp_path):
write(tmp_path / "Game_Data/Managed/Elringus.Naninovel.Runtime.dll")
write(tmp_path / "migration-staging/port/project.godot")
assert detect(tmp_path).engine == "naninovel"
def test_multiple_same_engine_games_are_not_merged(tmp_path):
write(tmp_path / "one/game/script.rpy", 'label start:\n "one"')
write(tmp_path / "two/game/script.rpy", 'label start:\n "two"')
with pytest.raises(CompatibilityError, match="несколько игр"):
detect(tmp_path)
def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
source = 'screen unused():\n text "UI"\nlabel start:\n "Hello"\n return\n'
write(tmp_path / "game/script.rpy", source)
assert inspect_directory(tmp_path)["compatible"]
write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen unused'))
with pytest.raises(CompatibilityError, match="call screen"):
inspect_directory(tmp_path)
@pytest.mark.parametrize("source,reason", [
('init python:\n def helper():\n pass\nlabel start:\n "Hi"', "init python"),
('label start:\n $ result = renpy.show_screen("map")', "renpy.show_screen"),
('label start:\n jump missing', "missing"),
])
def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
data = script(source)
audit_ir(data)
with pytest.raises(CompatibilityError, match=reason):
require_supported_scripts(data)
def test_directory_rejection_precedes_copying_media(tmp_path, monkeypatch):
write(tmp_path / "source/game/script.rpy", 'label start:\n call screen map')
def media(*args, **kwargs):
pytest.fail("Media must not be copied/converted for an unsupported game")
monkeypatch.setattr("app.converter.pipeline.shutil.copytree", media)
monkeypatch.setattr("app.converter.pipeline.assets.convert", media)
with pytest.raises(CompatibilityError):
convert_archive(tmp_path / "source", "source", tmp_path / "work", lambda _: None)
assert (tmp_path / "source/game/script.rpy").exists()
def test_archive_probe_does_not_extract_anything(tmp_path):
source = tmp_path / "game.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("Game/data.win", b"placeholder")
with pytest.raises(CompatibilityError) as exc:
probe_archive(source, source.name)
assert exc.value.engine == "gamemaker"
assert list(tmp_path.iterdir()) == [source]
def test_archive_probe_ignores_engine_runtime_scripts(tmp_path):
source = tmp_path / "game.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("Game/Game_Data/Managed/Elringus.Naninovel.Runtime.dll", b"")
archive.writestr("Game/renpy/common/script.rpy", b"")
assert probe_archive(source, source.name)["engine"] == "naninovel"
def test_script_preflight_does_not_unpack_loose_media(tmp_path):
from app.converter.archive import extract
source = tmp_path / "game.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("Game/game/script.rpy", 'label start:\n call screen map')
archive.writestr("Game/game/images/large.png", b"media")
destination = tmp_path / "scripts"
extract(source, source.name, destination, only_scripts=True)
assert (destination / "Game/game/script.rpy").exists()
assert not (destination / "Game/game/images").exists()
def test_batch_prefers_unpacked_duplicate_and_reports_executables(tmp_path):
(tmp_path / "Game").mkdir()
write(tmp_path / "Game.zip")
write(tmp_path / "Other.exe")
assert [p.name for p in entries(tmp_path)] == ["Game", "Other.exe"]
assert check(tmp_path / "Other.exe")["issues"][0]["code"] == "not_distribution"
def test_naninovel_decodes_line_order_and_dynamic_parameters():
tree = {"m_Name": "Opening", "lines": [{"id": 1}], "references": {"records": [
{"type": {"class": "PrintText"}, "data": {
"Text": {"hasValue": False, "value": "", "dynamicValue": {"ValueText": "Score: {score}"}},
"playbackSpot": {"lineIndex": 3}}},
{"type": {"class": "GenericTextScriptLine"}, "data": {
"lineIndex": 3, "lineHash": "original", "inlinedCommands": [{"id": 0}]}}]}}
decoded = decode_script(tree)
assert decoded["lines"][0]["commands"][0]["args"]["Text"] == {"template": "Score: {score}"}
tree["lines"][0]["id"] = -1
with pytest.raises(ValueError, match="ссылка"):
decode_script(tree)
def test_naninovel_compiles_dialogue_branches_choices_and_expressions():
original = package(command("SetCustomVariable", Expression="score=2"),
command("BeginIf", Expression="score>=1 && true"),
command("PrintText", Text={"template": "Score: {score}"}, AuthorId="Alice"),
command("Else"), command("PrintText", Text="Low"), command("EndIf"),
command("AddChoice", ChoiceSummary="Again", GotoPath="Opening"), command("Stop"))
data, entry, expressions = compile_package(original)
body = data.modules["Opening.nani"][0]["body"]
assert entry == "nani:Opening"
assert body[0]["code"][0]["v"]["k"] == "nani"
assert len(body[1]["branches"]) == 2
assert body[2]["op"] == "nani_add_choice" and body[3]["op"] == "nani_menu"
assert expressions == {"score": ["name", "score"]}
@pytest.mark.parametrize("commands,reason", [
([command("Spawn", Path="Minigame")], "Spawn"),
([command("PrintText", Text="Text", Unexpected=True)], "Unexpected"),
([command("SetCustomVariable", Expression="x=CustomFunction()")], "CustomFunction"),
([command("SetCustomVariable", Expression={"template": "x={other}"})], "Динамический"),
([command("Stop")], "внешнего интерфейса"),
([command("Goto", Path="Missing")], "цель"),
([command("BeginIf", Expression="true")], "Не закрыт"),
])
def test_naninovel_unknown_commands_functions_and_fields_are_fatal(commands, reason):
with pytest.raises(CompatibilityError, match=reason):
compile_package(package(*commands))
def test_naninovel_expression_parser_never_accepts_code():
ast = parse('score>1 ? "Yes" : "No"')
assert ast[0] == "if"
assert parse('"Привет\\n"')[1] == "Привет\n"
assert assignment("score=score+1")["v"]["k"] == "nani"
for source in ('__import__("os")', 'x.constructor()', 'x[0]', 'x; other()', 'x ? 1'):
with pytest.raises(ValueError):
parse(source)
def test_naninovel_resources_keep_keys_delimiters_and_context():
records = text_records("Tips", "# comment\nHeading\nHelp:Title|Description\n", "Tips")
assert [r["text"] for r in records.values()] == ["Title", "Description"]
assert len(text_records("_synonyms", "word=hello,hi\n", "synonyms")) == 2
with pytest.raises(ValueError, match="Повторяющийся"):
text_records("DefaultUI", "Key:A\nKey:B", "UI")
info = LineInfo("id", "node", "script/resources.json", "resources", "", "<b>Hello {name}</b>", None,
resource={"delimiters": "|\r\n"})
validate_nani_translation(info, "<b>Привет {name}</b>")
for invalid in ("Привет {name}", "<b>Привет {other}</b>", "<b>Привет {name}|test</b>"):
with pytest.raises(ValueError):
validate_nani_translation(info, invalid)
def test_naninovel_import_localization_and_mod_editor_roundtrip(tmp_path, monkeypatch):
from app.blobs import read_bytes
from app.converter.naninovel import convert_naninovel
from app.translation import apply_changes, locale_tables, text_index
from app.mods import document, validate
monkeypatch.setenv("DATA_DIR", str(tmp_path / "library"))
get_settings.cache_clear()
original = package(command("SetCustomVariable", Expression="score=3"),
command("PrintText", Text="Hello {score}"),
command("AddChoice", ChoiceSummary="Again", GotoPath="Opening"), command("Stop"))
original.texts = text_records("Tips", "Help:Title|Description\n", "Tips")
monkeypatch.setattr("app.converter.naninovel.read_package", lambda *args, **kwargs: original)
try:
result = convert_naninovel(tmp_path, tmp_path / "work", lambda _: None)
game = json.loads(read_bytes(result.manifest["files"]["game.json"]["hash"]))
assert game["engine"] == "naninovel" and game["entry"] == "nani:Opening"
index = text_index(result.manifest)
assert len(index.lines) == 4 # dialogue, choice, two independently addressed Tips parts
line = next(info for info in index.lines if info.source == "Hello {score}")
translated = apply_changes(result.manifest, "ru", lines={line.tid: {"t": "Привет {score}"}})
assert locale_tables(translated, "ru")[0][line.tid]["t"] == "Привет {score}"
with pytest.raises(ValueError, match="выражения"):
apply_changes(result.manifest, "ru", lines={line.tid: {"t": "Привет"}})
validate(document(translated), translated)
finally:
get_settings.cache_clear()
def test_unity_codec_restores_global_hooks_after_error():
from UnityPy.helpers import TypeTreeHelper as helper
from app.converter.unity_scripts import ScriptCodec
codec = ScriptCodec.__new__(ScriptCodec)
before = (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node)
with pytest.raises(RuntimeError):
with codec.active():
raise RuntimeError("broken object")
assert (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node) == before
def test_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatch):
from app.routers import versions
from app.uploads import upload_path
from fastapi import HTTPException
source = tmp_path / "unsupported.zip"
with zipfile.ZipFile(source, "w") as archive:
archive.writestr("data.win", b"game")
monkeypatch.setenv("DATA_DIR", str(tmp_path / "library"))
get_settings.cache_clear()
states = []
class DB:
version = None
def add(self, version):
version.id = 123
self.version = version
async def commit(self):
states.append(self.version.status)
async def delete(self, version):
assert version.status != "queued"
async def game(*_):
return SimpleNamespace(id=1)
async def stream():
yield source.read_bytes()
monkeypatch.setattr(versions, "get_game", game)
try:
with pytest.raises(HTTPException) as exc:
asyncio.run(versions.upload_version(1, SimpleNamespace(stream=stream), SimpleNamespace(id=1), DB(), source.name))
assert exc.value.status_code == 422
assert "gamemaker" in exc.value.detail
assert "queued" not in states
assert not upload_path(123).exists()
finally:
get_settings.cache_clear()
+27
View File
@@ -0,0 +1,27 @@
import asyncio
import os
import pytest
from app.config import get_settings
from app.maintenance import acquire, release
@pytest.mark.skipif(os.name == "nt", reason="Production shared flock semantics are POSIX")
def test_gc_excludes_active_writers(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
get_settings.cache_clear()
async def check():
writer = acquire()
gc = asyncio.create_task(asyncio.to_thread(acquire, True))
try:
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(asyncio.shield(gc), .1)
finally:
release(writer)
maintenance = await asyncio.wait_for(gc, 3)
release(maintenance)
try:
asyncio.run(check())
finally:
get_settings.cache_clear()
+163
View File
@@ -0,0 +1,163 @@
"""Integration against PostgreSQL in a temporary schema, never touches library rows."""
import asyncio
import copy
import os
from uuid import uuid4
import httpx
import pytest
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as database
from app.blobs import put_bytes
from app.db import Base
from app.main import app
from app.models import Game, GameVersion, Revision, Role, User
from app.permissions import ALL
from app.security import COOKIE_NAME, create_token
from tests.test_mods import pg_manifest # noqa: F401
@pytest.mark.skipif(not os.getenv("TEST_POSTGRES"), reason="Set TEST_POSTGRES=1 to use isolated PostgreSQL schema")
def test_complete_mod_workflow(pg_manifest, monkeypatch):
async def scenario():
schema = "test_mods_" + uuid4().hex
engine = create_async_engine(database.engine.url, connect_args={"server_settings": {"search_path": schema}})
factory = async_sessionmaker(engine, expire_on_commit=False)
monkeypatch.setattr(database, "SessionLocal", factory)
async with engine.begin() as conn:
await conn.execute(text(f'CREATE SCHEMA "{schema}"'))
await conn.run_sync(Base.metadata.create_all)
try:
async with factory() as db:
admin = Role(name="admin", permissions=ALL)
reader = Role(name="reader", permissions=["games.play"])
db.add_all([admin, reader])
await db.flush()
user = User(username="author", password_hash="unused", role_id=admin.id, must_change_password=False)
player = User(username="player", password_hash="unused", role_id=reader.id, must_change_password=False)
db.add_all([user, player])
await db.flush()
game = Game(title="Integration", created_by_id=user.id)
db.add(game)
await db.flush()
original = GameVersion(game_id=game.id, owner_id=user.id, name="Original", status="ready", manifest=pg_manifest)
db.add(original)
await db.commit()
original_id, user_id, player_id = original.id, user.id, player.id
original_revision = original.revision_id
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
hash = next(iter(pg_manifest["files"].values()))["hash"]
assert (await client.get(f"/blobs/{hash}")).status_code == 401
client.cookies.set(COOKIE_NAME, create_token(user_id, 0))
assert (await client.get(f"/blobs/{hash}")).status_code == 200
async def fork(name):
response = await client.post(f"/api/versions/{original_id}/fork", json={"name": name})
assert response.status_code == 201, response.text
return response.json()
a, b = await fork("A"), await fork("B")
assert a["base_revision_id"] == original_revision
edit = (await client.get(f'/api/versions/{a["id"]}/editor')).json()
doc = edit["document"]
script_path = next(p for p, m in doc["scripts"].items() if m["labels"])
first = doc["scripts"][script_path]["labels"][0]["body"]
first.insert(0, {"id": str(uuid4()), "op": "say", "tid": "integration_insert", "what": "Mod insertion"})
response = await client.put(f'/api/versions/{a["id"]}/editor', json={"revision": edit["revision"], "document": doc})
assert response.status_code == 200, response.text
a_revision = response.json()["revision"]
assert a_revision != edit["revision"]
assert (await client.put(f'/api/versions/{a["id"]}/editor', json={"revision": edit["revision"], "document": doc})).status_code == 409
# Draft-only media is unavailable to another player, even with the exact hash.
manifest = (await client.get(f'/api/versions/{a["id"]}/manifest')).json()
draft_hash = manifest["files"][script_path]["hash"]
client.cookies.set(COOKIE_NAME, create_token(player_id, 0))
assert (await client.get(f"/blobs/{draft_hash}")).status_code == 404
assert (await client.get(f'/api/versions/{a["id"]}/files/{draft_hash}')).status_code == 404
client.cookies.set(COOKIE_NAME, create_token(user_id, 0))
response = await client.get(f'/api/versions/{a["id"]}/files/{draft_hash}')
assert response.status_code == 200 and "private" in response.headers["cache-control"]
response = await client.get(f'/api/versions/{a["id"]}/files/{draft_hash}', headers={"If-None-Match": response.headers["etag"]})
assert response.status_code == 304
body = {"target_id": b["id"], "source_revision": a_revision, "target_revision": b["revision_id"]}
preview = await client.post(f'/api/versions/{a["id"]}/merge', json=body)
assert preview.status_code == 200 and not preview.json()["conflicts"], preview.text
result = await client.post(f'/api/versions/{a["id"]}/merge', json={**body, "apply": True})
assert result.status_code == 200, result.text
result_id = result.json()["id"]
revisions = (await client.get(f'/api/versions/{result_id}/revisions')).json()
assert {a_revision, b["revision_id"]} <= {r["id"] for r in revisions}
# A new independently imported original has unrelated IDs.
from app import mods
from app.converter.locales import walk
upstream = mods.prepare(mods.document(pg_manifest))
for module in upstream["scripts"].values():
for label in module["labels"]:
label["id"] = str(uuid4())
for n in walk(label["body"]):
n["id"] = str(uuid4())
for child in n.get("branches", []) + n.get("items", []):
child["id"] = str(uuid4())
upstream["scripts"][script_path]["labels"][0]["body"].append(
{"id": str(uuid4()), "op": "say", "tid": "upstream_new", "what": "Upstream addition"})
new_manifest = mods.store(upstream, pg_manifest)
async with factory() as db:
new_original = GameVersion(game_id=game.id, owner_id=user_id, name="New original", status="ready", manifest=new_manifest)
db.add(new_original)
await db.commit()
rebase_body = {"target_id": new_original.id, "source_revision": a_revision,
"target_revision": new_original.revision_id, "mode": "rebase"}
response = await client.post(f'/api/versions/{a["id"]}/merge', json=rebase_body)
assert response.status_code == 200 and not response.json()["conflicts"], response.text
response = await client.post(f'/api/versions/{a["id"]}/merge', json={**rebase_body, "apply": True})
assert response.status_code == 200, response.text
rebased = (await client.get(f'/api/versions/{response.json()["id"]}/editor')).json()
rebased_id = response.json()["id"]
lines = [n.get("what") for m in rebased["document"]["scripts"].values() for l in m["labels"] for n in walk(l["body"])]
assert "Mod insertion" in lines and "Upstream addition" in lines
# Repeating the transfer must use the target's canonical identity,
# rather than interpreting every original node as an inserted mod node.
response = await client.post(f'/api/versions/{rebased_id}/merge', json={
"target_id": new_original.id, "source_revision": rebased["revision"],
"target_revision": new_original.revision_id, "mode": "rebase", "apply": True})
assert response.status_code == 200, response.text
repeated = (await client.get(f'/api/versions/{response.json()["id"]}/editor')).json()
repeated_lines = [n.get("what") for m in repeated["document"]["scripts"].values() for l in m["labels"] for n in walk(l["body"])]
assert repeated_lines.count("Mod insertion") == 1
assert repeated_lines.count("Upstream addition") == 1
# A real replacement is converted, and a stale upload is refused.
import io
from PIL import Image
image = io.BytesIO()
Image.new("RGB", (32, 32), "red").save(image, "PNG")
asset_path = next(p for p in pg_manifest["files"] if p.endswith(".webp"))
response = await client.put(f'/api/versions/{a["id"]}/asset',
params={"path": asset_path, "revision": a_revision, "filename": "new.png"}, content=image.getvalue())
assert response.status_code == 200, response.text
replaced = response.json()
assert replaced["files"][asset_path]["hash"] != pg_manifest["files"][asset_path]["hash"]
a_revision = replaced["revision"]
# Base survives deletion of the parent version card.
assert (await client.delete(f"/api/versions/{original_id}")).status_code == 204
async with factory() as db:
assert await db.get(Revision, original_revision)
# Restore keeps a new revision and the old revision is still reachable.
response = await client.post(f'/api/versions/{a["id"]}/revisions/restore', json={"revision": a_revision, "restore": edit["revision"]})
assert response.status_code == 200, response.text
# GC preserves current/base/history; collects a genuinely unreferenced blob.
from app import gc
monkeypatch.setattr(gc, "SessionLocal", factory)
garbage_hash, _ = put_bytes(b"unreferenced-integration", ".bin")
report = await gc.collect(False)
assert report["files"] >= 1
from app.blobs import blob_path
assert blob_path(garbage_hash).exists()
await gc.collect(True)
assert not blob_path(garbage_hash).exists()
assert blob_path(draft_hash).exists() # retained ancestor
finally:
async with engine.begin() as conn:
await conn.execute(text(f'DROP SCHEMA "{schema}" CASCADE'))
await engine.dispose()
asyncio.run(scenario())
+114
View File
@@ -0,0 +1,114 @@
import copy
from uuid import uuid4
import pytest
from app import mods
from app.config import get_settings
from app.converter.pipeline import convert_archive
from app.translation import blob_json, file_json
from tests.make_sample import build
@pytest.fixture
def pg_manifest(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
get_settings.cache_clear()
blob_json.cache_clear()
yield convert_archive(build(tmp_path / "sample.zip"), "sample.zip", tmp_path / "work", lambda _: None).manifest
blob_json.cache_clear()
get_settings.cache_clear()
def node(id, text):
return {"id": id, "op": "say", "tid": "t_" + id, "what": text}
def merge(base, left, right, choices=None):
conflicts = []
out = mods.merge_value(base, left, right, "", conflicts, choices or {})
return out, conflicts
def test_editor_roundtrip_validation_and_anchors(pg_manifest):
doc = mods.prepare(mods.document(pg_manifest))
assert doc == mods.prepare(mods.document(pg_manifest))
mods.validate(doc, pg_manifest)
for module in doc["scripts"].values():
if module["labels"]:
module["labels"][0]["body"].insert(0, node(str(uuid4()), "Inserted"))
updated = mods.store(doc, pg_manifest)
assert mods.comparable(mods.document(updated))["scripts"] == mods.comparable(doc)["scripts"]
assert file_json(pg_manifest, "game.json") == doc["game"]
damaged = copy.deepcopy(doc)
first = next(m for m in damaged["scripts"].values() if m["labels"])["labels"][0]
first["body"].append(copy.deepcopy(first["body"][0]))
with pytest.raises(ValueError, match="ID"):
mods.validate(damaged, pg_manifest)
damaged = copy.deepcopy(doc)
next(m for m in damaged["scripts"].values() if m["labels"])["labels"][0]["body"].append({"op": "jump", "id": "bad", "target": "missing_label"})
with pytest.raises(ValueError, match="метку"):
mods.validate(damaged, pg_manifest)
def test_merge_disjoint_fields_and_insertions():
base = [node("a", "A"), node("b", "B"), node("c", "C")]
left = [node("a", "Changed"), node("x", "X"), node("b", "B"), node("c", "C")]
right = [node("a", "A"), node("b", "B"), node("y", "Y"), node("c", "Changed too")]
result, conflicts = merge(base, left, right)
assert not conflicts
assert [n["id"] for n in result] == ["a", "x", "b", "y", "c"]
assert result[0]["what"] == "Changed" and result[-1]["what"] == "Changed too"
def test_conflicting_edit_delete_and_order():
base = [node("a", "A"), node("b", "B")]
left = [node("a", "LEFT"), node("b", "B")]
right = [node("b", "B")]
_, conflicts = merge(base, left, right)
assert conflicts[0]["path"] == "/@a" and conflicts[0]["rightDeleted"]
result, conflicts = merge(base, left, right, {"/@a": "right"})
assert result == right and not conflicts
_, conflicts = merge(base, [base[0], node("x", "X"), base[1]], [base[0], node("y", "Y"), base[1]])
assert conflicts[0]["path"] == "/$order"
def test_asset_conflict_keeps_hash_size_atomic():
base = {"hash": "a", "size": 10}
left = {"hash": "b", "size": 10}
right = {"hash": "c", "size": 20}
assert merge(base, left, right, {"": "left"}) == (left, [])
def test_malformed_menu_condition_rejected(pg_manifest):
doc = mods.prepare(mods.document(pg_manifest))
from app.converter.locales import walk
menu = next(n for m in doc["scripts"].values() for l in m["labels"] for n in walk(l["body"]) if n["op"] == "menu")
menu["items"][0]["if"] = {"k": "run-javascript"}
with pytest.raises(ValueError, match="выражение"):
mods.validate(doc, pg_manifest)
def test_optional_nani_expression_contract():
from app.pgvn import expression
expression({"k": "nani", "ast": ["bin", "+", ["name", "score"], ["const", 1]]})
with pytest.raises(ValueError):
expression({"k": "nani", "ast": ["call", "eval", [["const", "alert(1)"]]]})
def test_rebase_does_not_trust_positional_ids():
def doc(nodes):
return {"game": {}, "scripts": {"script/a.json": {"labels": [{"id": "label", "name": "start", "body": nodes}]}}}
base = doc([node("1", "A"), node("2", "B")])
target = doc([node("1", "Inserted"), node("2", "A"), node("3", "B")])
aligned, unmatched = mods.align(base, target)
assert not unmatched
nodes = aligned["scripts"]["script/a.json"]["labels"][0]["body"]
assert nodes[0]["id"] not in ("1", "2")
assert [n["id"] for n in nodes[1:]] == ["1", "2"]
assert aligned == mods.align(base, target)[0]
target["scripts"]["script/a.json"]["labels"][0]["body"][1]["what"] = "Updated A"
_, unmatched = mods.align(base, target)
assert unmatched[0]["id"] == "1"
aligned, _ = mods.align(base, target, {"1": "2"})
assert aligned["scripts"]["script/a.json"]["labels"][0]["body"][1]["id"] == "1"
+46
View File
@@ -0,0 +1,46 @@
import asyncio
import json
import os
import subprocess
import sys
from uuid import uuid4
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.db import engine as configured_engine
@pytest.mark.skipif(not os.getenv("TEST_POSTGRES"), reason="Set TEST_POSTGRES=1 for isolated database migration test")
def test_upgrade_existing_versions_and_downgrade():
async def check():
name = "migration_test_" + uuid4().hex
admin = create_async_engine(configured_engine.url, isolation_level="AUTOCOMMIT")
test_url = configured_engine.url.set(database=name)
env = {**os.environ, "DATABASE_URL": test_url.render_as_string(hide_password=False)}
async def alembic(*args):
proc = await asyncio.to_thread(subprocess.run, [sys.executable, "-m", "alembic", *args], env=env, capture_output=True, text=True)
assert proc.returncode == 0, proc.stderr
async with admin.connect() as conn:
await conn.execute(text(f'CREATE DATABASE "{name}"'))
test_engine = create_async_engine(test_url)
try:
await alembic("upgrade", "0002")
async with test_engine.begin() as conn:
await conn.execute(text("INSERT INTO games(id,title,description,tags,engine) VALUES(1,'Old','','{}','renpy')"))
await conn.execute(text("INSERT INTO game_versions(id,game_id,name,description,status,progress,size_bytes,manifest) VALUES(1,1,'Original','','ready','',0,CAST(:m AS jsonb)),(2,1,'Legacy mod','','ready','',0,CAST(:m AS jsonb))"), {"m": json.dumps({"format": "pgvn/1", "files": {}})})
await conn.execute(text("UPDATE game_versions SET parent_id=1,kind='mod' WHERE id=2"))
await alembic("upgrade", "head")
async with test_engine.connect() as conn:
rows = (await conn.execute(text("SELECT revision_id,base_revision_id FROM game_versions"))).all()
assert len(rows) == 2 and all(r[0] and r[1] is None for r in rows)
assert await conn.scalar(text("SELECT count(*) FROM revisions")) == 2
await alembic("downgrade", "0002")
await alembic("upgrade", "head")
finally:
await test_engine.dispose()
async with admin.connect() as conn:
await conn.execute(text(f'DROP DATABASE "{name}" WITH (FORCE)'))
await admin.dispose()
asyncio.run(check())
+13
View File
@@ -56,6 +56,19 @@ def test_merge_languages_keeps_target_untouched(manifest):
assert "de" not in (tr.file_json(manifest, "game.json").get("locales") or {})
def test_three_way_publication_keeps_independent_lines(manifest):
a, b = [line.tid for line in tr.text_index(manifest).lines[:2]]
left = tr.apply_changes(manifest, "de", lines={a: {"t": "One"}})
right = tr.apply_changes(manifest, "de", lines={b: {"t": "Two"}})
merged, conflicts = tr.merge_translation(manifest, left, right)
assert not conflicts
lines = tr.locale_tables(merged, "de")[0]
assert lines[a]["t"] == "One" and lines[b]["t"] == "Two"
conflict = tr.apply_changes(manifest, "de", lines={a: {"t": "Other"}})
_, conflicts = tr.merge_translation(manifest, left, conflict)
assert conflicts and any(a in c["path"] for c in conflicts)
def test_po_and_csv_roundtrip(manifest):
tid = tr.text_index(manifest).lines[0].tid
m = tr.apply_changes(manifest, "de", lines={tid: {"t": "Zeile 1\nZeile \"2\""}},