left4me/l4d2web/tests/test_overlays.py
mwiegand df1ccb4cca
feat(l4d2-web): workshop overlay UI (routes + templates)
Adds workshop_routes blueprint with add-items / remove-item / manual-
build endpoints plus admin /admin/workshop/refresh. Add-items handles
single ID, single URL, multi-line batch, or a collection ID; auto-
enqueues a coalesced build_overlay job per call. Reject non-L4D2 items
with 400, duplicate associations with friendly toast, intruders with
403.

Generalizes overlay_routes: type+name only on create (no path field);
external is admin-only and system-wide, workshop is per-user and
auto-pathed. Update is name-only. Delete recursively removes the
on-disk dir only for managed paths (path == str(id)); legacy externals
are left in place. The pre-existing in-use guard is preserved.

Page routes filter the overlay listing by user permissions and load
workshop items + the latest related job for the detail view.

Templates: unified Create modal with type radio (no path field).
Type-aware overlay detail: workshop overlays show a multi-line input
+ items/collection radio + item table partial with thumbnails, manual
Rebuild button, and a small status indicator pulled from the latest
related job. Admin page gets a "Refresh all workshop items" button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:50:54 +02:00

295 lines
10 KiB
Python

import pytest
from l4d2web.app import create_app
from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, User
from l4d2web.services.security import validate_overlay_ref
@pytest.fixture
def admin_client(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'admin_overlay.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as session:
admin = User(username="admin", password_digest=hash_password("secret"), admin=True)
session.add(admin)
session.flush()
admin_id = admin.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = admin_id
sess["csrf_token"] = "test-token"
return client
@pytest.fixture
def user_client_with_overlay(tmp_path, monkeypatch):
db_url = f"sqlite:///{tmp_path/'user_overlay.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as session:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
session.add(user)
# System external overlay (no user_id), pre-existing.
session.add(Overlay(name="standard", path="standard", type="external", user_id=None))
session.flush()
user_id = user.id
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["csrf_token"] = "test-token"
return client
def test_user_can_view_overlay_catalog(user_client_with_overlay) -> None:
response = user_client_with_overlay.get("/overlays")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "standard" in text
# Non-admin users can create workshop overlays, so the Create button shows.
assert "Create overlay" in text
def test_admin_can_view_overlay_edit_controls(admin_client) -> None:
response = admin_client.get("/overlays")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "Create overlay" in text
assert 'action="/overlays"' in text
def test_admin_can_create_external_overlay(admin_client) -> None:
response = admin_client.post(
"/overlays",
data={"name": "standard", "type": "external"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
# Redirect to the new detail page now that paths are auto-generated.
assert response.headers["Location"].startswith("/overlays/")
@pytest.mark.parametrize("overlay_ref", [" standard", "standard ", "a//b", "a/", "./a", "a/.", "."])
def test_overlay_ref_rejects_unsafe_components(overlay_ref: str) -> None:
with pytest.raises(ValueError):
validate_overlay_ref(overlay_ref)
def test_non_admin_cannot_create_external_overlay(user_client_with_overlay) -> None:
response = user_client_with_overlay.post(
"/overlays",
data={"name": "bad", "type": "external"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 403
def test_user_can_create_workshop_overlay(user_client_with_overlay) -> None:
response = user_client_with_overlay.post(
"/overlays",
data={"name": "my-maps", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
with session_scope() as session:
overlay = session.query(Overlay).filter_by(name="my-maps").one()
assert overlay.type == "workshop"
assert overlay.user_id is not None
assert overlay.path == str(overlay.id)
def test_workshop_overlay_directory_is_created_on_disk(user_client_with_overlay, tmp_path) -> None:
response = user_client_with_overlay.post(
"/overlays",
data={"name": "my-maps", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
with session_scope() as session:
overlay = session.query(Overlay).filter_by(name="my-maps").one()
overlay_id = overlay.id
assert (tmp_path / "overlays" / str(overlay_id)).is_dir()
def test_two_users_can_have_workshop_overlay_with_same_name(tmp_path, monkeypatch) -> None:
# Set up a fresh app with two users.
db_url = f"sqlite:///{tmp_path/'shared.db'}"
monkeypatch.setenv("DATABASE_URL", db_url)
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"})
init_db()
with session_scope() as session:
for username in ("alice", "bob"):
session.add(User(username=username, password_digest=hash_password("x"), admin=False))
session.flush()
alice_id, bob_id = (
session.query(User).filter_by(username="alice").one().id,
session.query(User).filter_by(username="bob").one().id,
)
def client_for(uid):
c = app.test_client()
with c.session_transaction() as sess:
sess["user_id"] = uid
sess["csrf_token"] = "test-token"
return c
for uid in (alice_id, bob_id):
r = client_for(uid).post(
"/overlays",
data={"name": "my-maps", "type": "workshop"},
headers={"X-CSRF-Token": "test-token"},
)
assert r.status_code == 302
with session_scope() as session:
rows = session.query(Overlay).filter_by(name="my-maps").all()
assert {r.user_id for r in rows} == {alice_id, bob_id}
def test_admin_can_update_and_delete_overlay(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "type": "external"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
overlay_id = session.query(Overlay).filter_by(name="standard").one().id
update = admin_client.post(
f"/overlays/{overlay_id}",
data={"name": "edited"},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
delete = admin_client.post(
f"/overlays/{overlay_id}/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert delete.status_code == 302
def test_update_overlay_rejects_duplicate_name(admin_client) -> None:
ids: list[int] = []
for name in ("standard", "competitive"):
response = admin_client.post(
"/overlays",
data={"name": name, "type": "external"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
with session_scope() as session:
ids = [
session.query(Overlay).filter_by(name="standard").one().id,
session.query(Overlay).filter_by(name="competitive").one().id,
]
response = admin_client.post(
f"/overlays/{ids[1]}",
data={"name": "standard"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 409
def test_overlay_detail_page_lists_using_blueprints(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "shared", "type": "external"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
overlay_id = session.query(Overlay).filter_by(name="shared").one().id
with session_scope() as session:
admin = session.query(User).filter_by(username="admin").one()
bp_one = Blueprint(user_id=admin.id, name="alpha-bp", arguments="[]", config="[]")
bp_two = Blueprint(user_id=admin.id, name="beta-bp", arguments="[]", config="[]")
session.add_all([bp_one, bp_two])
session.flush()
session.add(BlueprintOverlay(blueprint_id=bp_one.id, overlay_id=overlay_id, position=0))
session.add(BlueprintOverlay(blueprint_id=bp_two.id, overlay_id=overlay_id, position=0))
response = admin_client.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "alpha-bp" in text
assert "beta-bp" in text
assert "Used by" in text
def test_overlay_detail_page_404_when_missing(admin_client) -> None:
response = admin_client.get("/overlays/999")
assert response.status_code == 404
def test_overlay_detail_hides_edit_for_non_admin_external(user_client_with_overlay) -> None:
# The seeded "standard" external overlay (id=1, user_id=NULL) is admin-only edit.
response = user_client_with_overlay.get("/overlays/1")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert "standard" in text
assert 'action="/overlays/1"' not in text
assert "delete-overlay-modal" not in text
def test_overlay_update_redirects_to_detail(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "type": "external"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
overlay_id = session.query(Overlay).filter_by(name="standard").one().id
response = admin_client.post(
f"/overlays/{overlay_id}",
data={"name": "renamed"},
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 302
assert response.headers["Location"] == f"/overlays/{overlay_id}"
def test_delete_overlay_rejects_in_use_overlay(admin_client) -> None:
create = admin_client.post(
"/overlays",
data={"name": "standard", "type": "external"},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
with session_scope() as session:
overlay_id = session.query(Overlay).filter_by(name="standard").one().id
with session_scope() as session:
user = session.query(User).filter_by(username="admin").one()
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
session.add(blueprint)
session.flush()
session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=overlay_id, position=0))
response = admin_client.post(
f"/overlays/{overlay_id}/delete",
headers={"X-CSRF-Token": "test-token"},
)
assert response.status_code == 409