Files
pgames-manager/backend/tests/test_mod_api.py
T
mrleo1nid c8d83b3fe7 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.
2026-10-25 04:42:48 +03:00

164 lines
11 KiB
Python

"""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())